iT邦幫忙

2025 iThome 鐵人賽

DAY 0
0
Modern Web

前端工程師的 Modern Web 實踐之道系列 第 12

響應式設計 2.0:Container Queries 與現代化布局技術

  • 分享至 

  • xImage
  •  

系列文章: 前端工程師的 Modern Web 實踐之道 - Day 12
預計閱讀時間: 12 分鐘
難度等級: ⭐⭐⭐⭐☆

🎯 今日目標

在前一篇文章中,我們探討了 API 設計與前端整合的最佳實踐。今天我們將深入探討現代響應式設計的革命性技術:Container Queries,以及其他現代化布局技術。這些技術將徹底改變我們設計響應式介面的方式。

為什麼響應式設計需要升級?

  • 組件化時代的挑戰: 傳統 Media Queries 基於視窗寬度,無法適應組件在不同容器中的使用
  • 設計系統的需求: 現代設計系統需要真正可複用的響應式組件
  • 效能與維護性: Container Queries 讓響應式邏輯更內聚,減少 70% 的媒體查詢程式碼
  • 使用者體驗提升: 更精確的布局控制能提供更好的閱讀和互動體驗

還在為一個組件在側邊欄和主內容區表現不一致而煩惱嗎?每次調整布局都要修改一堆 Media Queries?今天我們來聊聊如何用現代技術徹底解決這些問題。

🔍 深度分析:響應式設計的演進

從 Media Queries 到 Container Queries

傳統 Media Queries 的限制

/* 傳統做法:基於視窗寬度 */
.card {
  display: flex;
  flex-direction: column;
}

@media (min-width: 768px) {
  .card {
    flex-direction: row;
  }
}

/* 問題:
   - 無法知道卡片實際的可用空間
   - 在側邊欄中可能需要垂直排列
   - 在主內容區可能需要水平排列
   - 需要為每個使用場景寫不同的規則
*/

實際場景的痛點:

<!-- 同一個 Card 組件在不同容器中 -->
<div class="sidebar">     <!-- 寬度 300px -->
  <div class="card">...</div>
</div>

<div class="main-content"> <!-- 寬度 900px -->
  <div class="card">...</div>
</div>

<!-- 傳統方案需要:
  1. 為 sidebar 內的 card 添加特殊類名
  2. 或使用複雜的選擇器
  3. 或寫大量條件判斷的 CSS
-->

Container Queries:真正的組件響應式

/* 現代做法:基於容器寬度 */
.card-container {
  container-type: inline-size;
  container-name: card;
}

.card {
  display: flex;
  flex-direction: column;
}

/* 當容器寬度 >= 500px 時 */
@container card (min-width: 500px) {
  .card {
    flex-direction: row;
  }
}

/* 優勢:
   - 組件根據自身容器調整
   - 完全可複用
   - 不依賴頁面布局
   - 真正的組件化響應式設計
*/

現代布局技術棧

CSS Grid:二維布局的最佳選擇

Grid 的強大之處:

/* 傳統做法需要大量計算和嵌套 */
.traditional-layout {
  /* 複雜的 float、position 組合 */
}

/* Grid 一行搞定 */
.modern-layout {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 2rem;
}

Flexbox:一維布局的完美工具

/* 智能間距分配 */
.flex-layout {
  display: flex;
  gap: 1rem;          /* 現代間距控制 */
  flex-wrap: wrap;    /* 自動換行 */
}

.flex-item {
  flex: 1 1 300px;    /* 彈性基準 300px */
}

💻 實戰演練:三個完整實作範例

實作範例 1:智能響應式卡片組件

讓我們打造一個真正智能的卡片組件,能根據容器寬度自動調整布局:

<!DOCTYPE html>
<html lang="zh-TW">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Container Queries 實戰</title>
  <style>
    /* 1. 基礎樣式重置 */
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
      line-height: 1.6;
      padding: 2rem;
      background: #f5f5f5;
    }

    /* 2. 定義容器查詢 */
    .card-wrapper {
      container-type: inline-size;
      container-name: card-container;
      margin-bottom: 2rem;
    }

    /* 3. 卡片基礎樣式 */
    .smart-card {
      background: white;
      border-radius: 8px;
      overflow: hidden;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
      transition: all 0.3s ease;
    }

    .smart-card:hover {
      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
      transform: translateY(-2px);
    }

    /* 4. 內容布局 - 預設垂直 */
    .card-content {
      display: flex;
      flex-direction: column;
    }

    .card-image {
      width: 100%;
      height: 200px;
      object-fit: cover;
    }

    .card-body {
      padding: 1.5rem;
    }

    .card-title {
      font-size: 1.25rem;
      font-weight: 600;
      margin-bottom: 0.5rem;
      color: #333;
    }

    .card-description {
      color: #666;
      margin-bottom: 1rem;
    }

    .card-meta {
      display: flex;
      gap: 1rem;
      font-size: 0.875rem;
      color: #999;
    }

    /* 5. Container Query: 小容器 (300px+) */
    @container card-container (min-width: 300px) {
      .card-title {
        font-size: 1.125rem;
      }
    }

    /* 6. Container Query: 中等容器 (500px+) - 改為水平布局 */
    @container card-container (min-width: 500px) {
      .card-content {
        flex-direction: row;
      }

      .card-image {
        width: 40%;
        height: auto;
        min-height: 250px;
      }

      .card-body {
        width: 60%;
        padding: 2rem;
      }

      .card-title {
        font-size: 1.5rem;
      }
    }

    /* 7. Container Query: 大容器 (700px+) - 增強視覺效果 */
    @container card-container (min-width: 700px) {
      .card-body {
        padding: 2.5rem;
      }

      .card-title {
        font-size: 1.75rem;
        margin-bottom: 1rem;
      }

      .card-description {
        font-size: 1.125rem;
        line-height: 1.8;
      }

      .card-meta {
        font-size: 1rem;
        margin-top: 1.5rem;
      }
    }

    /* 8. 示範不同寬度的容器 */
    .demo-container {
      display: grid;
      gap: 2rem;
      margin-top: 2rem;
    }

    .narrow {
      max-width: 350px;
    }

    .medium {
      max-width: 600px;
    }

    .wide {
      max-width: 900px;
    }

    /* 9. 標籤樣式 */
    .container-label {
      display: inline-block;
      padding: 0.5rem 1rem;
      background: #3b82f6;
      color: white;
      border-radius: 4px;
      font-size: 0.875rem;
      font-weight: 500;
      margin-bottom: 1rem;
    }

    /* 10. 響應式圖片最佳化 */
    .card-image {
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    }
  </style>
</head>
<body>
  <h1 style="margin-bottom: 2rem;">Container Queries 實戰示範</h1>

  <div class="demo-container">
    <!-- 窄容器示範 -->
    <div>
      <div class="container-label">窄容器 (350px)</div>
      <div class="card-wrapper narrow">
        <article class="smart-card">
          <div class="card-content">
            <img src="https://picsum.photos/400/300" alt="範例圖片" class="card-image">
            <div class="card-body">
              <h2 class="card-title">智能響應式卡片</h2>
              <p class="card-description">
                這個卡片會根據容器寬度自動調整布局,在窄容器中垂直排列。
              </p>
              <div class="card-meta">
                <span>📅 2025-01-15</span>
                <span>👤 作者</span>
              </div>
            </div>
          </div>
        </article>
      </div>
    </div>

    <!-- 中等容器示範 -->
    <div>
      <div class="container-label">中等容器 (600px)</div>
      <div class="card-wrapper medium">
        <article class="smart-card">
          <div class="card-content">
            <img src="https://picsum.photos/400/300" alt="範例圖片" class="card-image">
            <div class="card-body">
              <h2 class="card-title">智能響應式卡片</h2>
              <p class="card-description">
                在中等容器中,卡片自動切換為水平布局,圖片和內容並排顯示。
              </p>
              <div class="card-meta">
                <span>📅 2025-01-15</span>
                <span>👤 作者</span>
                <span>💬 24 留言</span>
              </div>
            </div>
          </div>
        </article>
      </div>
    </div>

    <!-- 寬容器示範 -->
    <div>
      <div class="container-label">寬容器 (900px)</div>
      <div class="card-wrapper wide">
        <article class="smart-card">
          <div class="card-content">
            <img src="https://picsum.photos/400/300" alt="範例圖片" class="card-image">
            <div class="card-body">
              <h2 class="card-title">智能響應式卡片</h2>
              <p class="card-description">
                在寬容器中,字體和間距都會增大,提供更舒適的閱讀體驗。同時保持完美的視覺比例。
              </p>
              <div class="card-meta">
                <span>📅 2025-01-15</span>
                <span>👤 作者</span>
                <span>💬 24 留言</span>
                <span>❤️ 128 喜歡</span>
              </div>
            </div>
          </div>
        </article>
      </div>
    </div>
  </div>

  <script>
    // 動態顯示容器實際寬度
    const updateContainerWidths = () => {
      document.querySelectorAll('.card-wrapper').forEach(wrapper => {
        const width = wrapper.offsetWidth;
        const label = wrapper.previousElementSibling;
        if (label && label.classList.contains('container-label')) {
          label.textContent = label.textContent.split('(')[0] + `(${width}px)`;
        }
      });
    };

    window.addEventListener('resize', updateContainerWidths);
    updateContainerWidths();
  </script>
</body>
</html>

實作範例 2:現代 Grid 布局系統

建立一個完整的響應式網格系統,支援自動填充和智能間距:

/**
 * 現代化 Grid 布局系統
 * 特性:自動響應、智能間距、無需媒體查詢
 */

/* 1. 基礎網格容器 */
.grid-auto {
  display: grid;
  gap: var(--grid-gap, 1.5rem);
  /* 自動填充:最小 250px,最大 1fr */
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
}

/* 2. 響應式列數控制 */
.grid-2 {
  display: grid;
  gap: 1.5rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
}

.grid-3 {
  display: grid;
  gap: 1.5rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
}

.grid-4 {
  display: grid;
  gap: 1.5rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
}

/* 3. 不等寬網格(聖杯布局)*/
.holy-grail-layout {
  display: grid;
  gap: 2rem;
  /*
     auto: 側邊欄自適應內容
     1fr: 主內容佔據剩餘空間
  */
  grid-template-columns: auto 1fr auto;
  grid-template-areas:
    "header header header"
    "sidebar-left main sidebar-right"
    "footer footer footer";
  min-height: 100vh;
}

/* 響應式聖杯布局 */
@media (max-width: 768px) {
  .holy-grail-layout {
    grid-template-columns: 1fr;
    grid-template-areas:
      "header"
      "main"
      "sidebar-left"
      "sidebar-right"
      "footer";
  }
}

/* 4. 複雜網格:不規則布局 */
.masonry-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  grid-auto-rows: 20px; /* 小單元高度 */
  gap: 1rem;
}

.masonry-item {
  /* 根據內容高度佔據不同行數 */
}

.masonry-item.small {
  grid-row: span 10;  /* 200px */
}

.masonry-item.medium {
  grid-row: span 15;  /* 300px */
}

.masonry-item.large {
  grid-row: span 20;  /* 400px */
}

/* 5. Grid 配合 Container Queries */
.adaptive-grid {
  container-type: inline-size;
  display: grid;
  gap: 1rem;
}

/* 容器小於 400px:單列 */
@container (max-width: 400px) {
  .adaptive-grid {
    grid-template-columns: 1fr;
  }
}

/* 容器 400px - 700px:雙列 */
@container (min-width: 400px) and (max-width: 700px) {
  .adaptive-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* 容器大於 700px:三列 */
@container (min-width: 700px) {
  .adaptive-grid {
    grid-template-columns: repeat(3, 1fr);
  }
}

/* 6. 進階:子網格 (Subgrid) */
.parent-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 2rem;
}

.child-grid {
  display: grid;
  /* 繼承父網格的列定義 */
  grid-template-columns: subgrid;
  grid-column: span 3;
  gap: 1rem;
}

/* 7. 實用工具類 */
.full-width {
  grid-column: 1 / -1;  /* 佔據所有列 */
}

.span-2 {
  grid-column: span 2;
}

.span-3 {
  grid-column: span 3;
}

/* 8. 智能對齊 */
.grid-center {
  place-items: center;  /* 水平和垂直居中 */
}

.grid-start {
  place-items: start;
}

.grid-stretch {
  place-items: stretch;
}

完整 HTML 示例:

<!DOCTYPE html>
<html lang="zh-TW">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>現代 Grid 布局系統</title>
  <style>
    /* 引入上面的 CSS */

    /* 示範樣式 */
    .demo-card {
      background: white;
      border-radius: 8px;
      padding: 2rem;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    }

    .demo-card h3 {
      margin-bottom: 1rem;
      color: #333;
    }

    .demo-card p {
      color: #666;
      line-height: 1.6;
    }
  </style>
</head>
<body>
  <!-- 自動響應網格 -->
  <section class="grid-auto" style="padding: 2rem;">
    <div class="demo-card small">
      <h3>卡片 1</h3>
      <p>自動調整寬度的網格項目</p>
    </div>
    <div class="demo-card medium">
      <h3>卡片 2</h3>
      <p>內容較多的卡片會自動擴展高度</p>
    </div>
    <div class="demo-card large">
      <h3>卡片 3</h3>
      <p>Grid 會自動計算最佳列數</p>
    </div>
    <div class="demo-card small">
      <h3>卡片 4</h3>
      <p>無需媒體查詢</p>
    </div>
  </section>

  <!-- 聖杯布局 -->
  <div class="holy-grail-layout">
    <header style="grid-area: header;">
      <h1>Header</h1>
    </header>

    <aside style="grid-area: sidebar-left;">
      <nav>左側導航</nav>
    </aside>

    <main style="grid-area: main;">
      <article>主要內容</article>
    </main>

    <aside style="grid-area: sidebar-right;">
      <div>右側廣告</div>
    </aside>

    <footer style="grid-area: footer;">
      <p>Footer</p>
    </footer>
  </div>
</body>
</html>

實作範例 3:響應式圖片和媒體處理

現代化的圖片載入和顯示策略:

<!DOCTYPE html>
<html lang="zh-TW">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>響應式圖片最佳實踐</title>
  <style>
    /* 1. 基礎響應式圖片 */
    .responsive-img {
      width: 100%;
      height: auto;
      display: block;
    }

    /* 2. 保持比例的圖片容器 */
    .img-container {
      position: relative;
      width: 100%;
      /* 16:9 比例 */
      aspect-ratio: 16 / 9;
      overflow: hidden;
      background: #f0f0f0;
    }

    .img-container img {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      object-fit: cover;
      /* 延遲載入的淡入效果 */
      opacity: 0;
      transition: opacity 0.3s ease;
    }

    .img-container img.loaded {
      opacity: 1;
    }

    /* 3. Art Direction:不同螢幕顯示不同裁切 */
    .art-direction-container {
      container-type: inline-size;
    }

    /* 4. 載入狀態 */
    .img-container::before {
      content: '';
      position: absolute;
      top: 50%;
      left: 50%;
      width: 40px;
      height: 40px;
      margin: -20px 0 0 -20px;
      border: 3px solid #f3f3f3;
      border-top: 3px solid #3b82f6;
      border-radius: 50%;
      animation: spin 1s linear infinite;
    }

    .img-container.loaded::before {
      display: none;
    }

    @keyframes spin {
      0% { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
    }

    /* 5. 圖片網格布局 */
    .image-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
      gap: 1rem;
      padding: 2rem;
    }

    /* 6. 模糊載入效果 */
    .blur-load {
      position: relative;
      background-size: cover;
      background-position: center;
    }

    .blur-load::before {
      content: '';
      position: absolute;
      inset: 0;
      backdrop-filter: blur(20px);
      transition: opacity 0.3s ease;
    }

    .blur-load.loaded::before {
      opacity: 0;
    }

    /* 7. 不同比例的圖片 */
    .square {
      aspect-ratio: 1 / 1;
    }

    .portrait {
      aspect-ratio: 3 / 4;
    }

    .landscape {
      aspect-ratio: 16 / 9;
    }

    .ultrawide {
      aspect-ratio: 21 / 9;
    }
  </style>
</head>
<body>
  <!-- 1. 基礎響應式圖片 -->
  <section>
    <h2>基礎響應式圖片</h2>
    <img
      src="image-small.jpg"
      srcset="
        image-small.jpg 400w,
        image-medium.jpg 800w,
        image-large.jpg 1200w
      "
      sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw"
      alt="響應式圖片"
      class="responsive-img"
      loading="lazy"
    >
  </section>

  <!-- 2. Art Direction:不同裁切 -->
  <section>
    <h2>Art Direction</h2>
    <picture>
      <!-- 行動裝置:直式裁切 -->
      <source
        media="(max-width: 767px)"
        srcset="portrait-small.jpg"
      >
      <!-- 平板:方形裁切 -->
      <source
        media="(max-width: 1023px)"
        srcset="square-medium.jpg"
      >
      <!-- 桌面:橫式裁切 -->
      <img
        src="landscape-large.jpg"
        alt="不同裝置顯示不同裁切"
        class="responsive-img"
      >
    </picture>
  </section>

  <!-- 3. 現代圖片格式 -->
  <section>
    <h2>現代圖片格式(WebP/AVIF)</h2>
    <picture>
      <!-- 優先使用 AVIF -->
      <source
        type="image/avif"
        srcset="image.avif"
      >
      <!-- 其次使用 WebP -->
      <source
        type="image/webp"
        srcset="image.webp"
      >
      <!-- 最後使用 JPEG 作為後備 -->
      <img
        src="image.jpg"
        alt="現代圖片格式"
        class="responsive-img"
      >
    </picture>
  </section>

  <!-- 4. 延遲載入的圖片網格 -->
  <section>
    <h2>延遲載入圖片網格</h2>
    <div class="image-grid">
      <div class="img-container square">
        <img
          data-src="https://picsum.photos/400/400"
          alt="圖片 1"
          loading="lazy"
          class="lazy-img"
        >
      </div>
      <div class="img-container landscape">
        <img
          data-src="https://picsum.photos/800/450"
          alt="圖片 2"
          loading="lazy"
          class="lazy-img"
        >
      </div>
      <div class="img-container portrait">
        <img
          data-src="https://picsum.photos/600/800"
          alt="圖片 3"
          loading="lazy"
          class="lazy-img"
        >
      </div>
    </div>
  </section>

  <script>
    // 圖片延遲載入與淡入效果
    class LazyImageLoader {
      constructor() {
        this.images = document.querySelectorAll('.lazy-img');
        this.options = {
          root: null,
          rootMargin: '50px',
          threshold: 0.01
        };

        this.observer = new IntersectionObserver(
          this.handleIntersection.bind(this),
          this.options
        );

        this.init();
      }

      init() {
        this.images.forEach(img => {
          this.observer.observe(img);
        });
      }

      handleIntersection(entries) {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            this.loadImage(entry.target);
            this.observer.unobserve(entry.target);
          }
        });
      }

      loadImage(img) {
        const src = img.dataset.src;
        if (!src) return;

        // 建立新的圖片物件以預載
        const loader = new Image();

        loader.onload = () => {
          img.src = src;
          img.classList.add('loaded');
          img.parentElement.classList.add('loaded');
        };

        loader.onerror = () => {
          console.error(`Failed to load image: ${src}`);
          img.parentElement.classList.add('error');
        };

        loader.src = src;
      }
    }

    // 初始化延遲載入
    document.addEventListener('DOMContentLoaded', () => {
      new LazyImageLoader();
    });

    // 模糊載入效果(進階版)
    class BlurImageLoader {
      constructor(element) {
        this.element = element;
        this.lowResSrc = element.dataset.lowres;
        this.highResSrc = element.dataset.highres;

        this.loadImages();
      }

      loadImages() {
        // 先載入低解析度版本
        if (this.lowResSrc) {
          this.element.style.backgroundImage = `url(${this.lowResSrc})`;
        }

        // 然後載入高解析度版本
        const img = new Image();
        img.onload = () => {
          this.element.style.backgroundImage = `url(${this.highResSrc})`;
          this.element.classList.add('loaded');
        };
        img.src = this.highResSrc;
      }
    }

    // 使用範例
    document.querySelectorAll('.blur-load').forEach(el => {
      new BlurImageLoader(el);
    });
  </script>
</body>
</html>

📋 本日重點回顧

  1. 核心概念: Container Queries 是響應式設計的革命性技術,讓組件真正可複用。傳統 Media Queries 基於視窗,Container Queries 基於容器,更符合組件化開發。

  2. 關鍵技術:

    • Container Queries 讓響應式邏輯更內聚,減少 70% 的媒體查詢程式碼
    • CSS Grid 的 auto-fitminmax 實現真正的自適應布局
    • aspect-ratio 屬性簡化圖片比例控制
    • 原生延遲載入(loading="lazy")提升載入效能
  3. 實踐要點:

    • 使用 container-type: inline-size 定義容器查詢環境
    • Grid 配合 Container Queries 實現完全自適應的布局
    • 響應式圖片要同時考慮解析度和裁切方向
    • 使用現代圖片格式(AVIF、WebP)減少 50% 檔案大小

🎯 最佳實踐建議

✅ 推薦做法:

  • 使用 Container Queries 設計可複用組件,不依賴頁面布局
  • 優先使用 CSS Grid 進行二維布局,Flexbox 處理一維布局
  • 圖片使用 srcsetsizes 提供多種解析度
  • 使用 aspect-ratio 防止內容跳動(CLS)

❌ 避免陷阱:

  • 不要過度使用 Container Queries,簡單場景用 Grid 自適應即可
  • 避免在沒有 container-type 的元素上使用 @container
  • 不要忽略 Container Queries 的瀏覽器相容性(需要 polyfill)
  • 避免巢狀過深的容器查詢,影響效能和可維護性

🤔 延伸思考

  1. 漸進增強: 如何在不支援 Container Queries 的瀏覽器提供後備方案?

  2. 效能考量: Container Queries 對渲染效能有什麼影響?如何最佳化?

  3. 實踐挑戰: 嘗試將現有專案的 Media Queries 重構為 Container Queries,測量程式碼減少量和可維護性提升。


上一篇
RESTful 到 GraphQL 的實踐經驗
下一篇
使用者體驗最佳化:從載入效能到互動設計的現代化實踐
系列文
前端工程師的 Modern Web 實踐之道13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言